Skip to content

Every key paints within a frame: typing echoes in 2 ms, session switches are instant, and git-backed views never freeze the UI - #84

Merged
webdevcody merged 3 commits into
mainfrom
velvet-meadow-sails
Sep 17, 2026
Merged

webdevcody merged 3 commits into
mainfrom
velvet-meadow-sails

Conversation

@webdevcody

Copy link
Copy Markdown
Contributor

Every action in the TUI was timed against the real app, and everything that kept a key from painting inside a frame was fixed: a character typed at an agent echoes in 2.3 ms where it took 19.8, no scripted step waits more than 6 ms for its frame (12 of 123 used to wait over 16), and on a ten-thousand-file checkout g no longer freezes the UI for a quarter of a second. Peak memory is flat to slightly lower — the pane's screen cache got faster by holding less.

Contents: 📊 Scorecard · ✨ What changed · 📸 Screenshots · 🧭 Where the time goes · ⚠️ Risk · 🔧 Technical overview · 📝 Notes

📊 Scorecard

Release build, this repository as the checkout, a stand-in agent with a full 1 MB ring. Paint is key → the frame that shows it; settle is key → the screen in its final state; frozen is how long the key's handler held the whole event loop.

Metric Before After Δ Measured with
Typed character → echo painted, LOCKED PANE 19.8 ms 2.3 ms −88 % make perf BIN=target/release/nebula
Session switch, j/k or click (settle) 18.5–20.7 ms 1.0–1.4 ms −93 %
] attention jump · workspace switch (settle) 22.3 · 19.2 ms 1.6 · 1.7 ms −92 %
Archive · delete a session (settle) 29.0 · 20.2 ms 1.1 · 1.7 ms −95 %
g DIFF VIEWER: frozen → paint 44.1 ms frozen 2.0 ms −96 %
g DIFF VIEWER: first diff on screen 45.9 ms 13.2 ms −71 %
to the next file's diff 12.5 ms 1.2 ms −90 %
f · b open (paint; the list lands at ~13 ms as before) 13.4 · 13.7 ms frozen 1.3 · 2.0 ms −88 %
F grep, per character typed 24.3 ms frozen 0.0 ms gone
Shift+G repo page 11.3 ms 1.1 ms −90 %
Startup → first frame with the DAEMON's snapshot 40.6 ms 26.1 ms −36 %
Worst paint in the scenario 45.9 ms 6.0 ms −87 %
Steps over one 16 ms frame 12 / 123 0 / 123
Peak RSS, TUI 55.6 MB 54.0 MB −1.6 MB ps -o rss, sampled 2×/s by the harness
Peak RSS, DAEMON 21.7 MB 19.7 MB −2.0 MB

The same run against a clone of a ten-thousand-file checkout, in the dev build make dev runs — where git has real work to do and this crate is unoptimised:

Metric Before After Measured with
g DIFF VIEWER 262 ms frozen list in 1.4 ms, first diff at 59 ms PERF_REPO=… make perf
to the next file's diff 54 ms frozen 0.7 ms
f · b open 76 · 98 ms frozen 0.8 · 1.3 ms
F grep, per character typed ~370 ms frozen 0.0 ms
Filter typing in f · b, per character 27 ms 5–8 ms
Before After
Unit tests, TUI + matcher 927 949 cargo test -p nebula-tui -p nebula-fuzzy
Unit tests, DAEMON 278 280 cargo test -p nebula-daemon
e2e_tui 9 pass, 2 fail 11 pass make ci — the two failed on untouched main too, see Notes
Lines (net) +3734 −570, 36 files git diff --stat origin/main
PROTOCOL VERSION unchanged unchanged no shared type moved

One number that reads worse and is not: grep's results now land ~66 ms after the last character (a 40 ms debounce, then git), where they used to be on screen with each character — because each character froze the UI until they were. A nine-letter word was nine freezes.

✨ What changed

  • Typing at an agent is immediate — three fixes on one path.
    • Frames are paced by a token bucket instead of a fixed 16 ms tick: a key's frame and its answer's go out 2 ms apart; sustained PTY output still paints at 60 fps.
    • A key that only goes to the PTY paints no frame of its own — the identical frame it used to paint was what the echo then queued behind.
    • The DAEMON sends PTY output that breaks a silence at once; the 5 ms coalescing hold still applies to a stream, exactly as before.
  • g, f, b and F open on the keypress and never freeze the UI.
    • The modal is up at once and fills in when git status / git ls-files answer; what you type meanwhile is kept and applied.
    • g opens on the list the changed-files badge already has, so the first diff is being read while the list is checked.
    • in the DIFF VIEWER finds the next diff already read; up to 2 MB of diffs are kept while the modal is open and freed when it closes.
    • git grep waits 40 ms for the next character, streams, and is killed at 200 hits or when the query moves on.
  • Switching back to a session is instant.
    • The last six screens shown are kept (it was two), inside half the memory two were allowed.
    • A screen with a long history keeps the screen and lets the scrollback go; scrolling up in it replays the ring once and lands where the wheel was headed.
  • Rename, archive, unarchive, delete and close happen on the keypress.
    • The row changes at once; if the DAEMON refuses, it goes back and the footer says why.
    • A delete's row cannot flicker back from an upsert that was already on its way.
  • Nothing else on a key path waits either.
    • Opening a link or the repo page (Shift+G), a Ghostty tab (Shift+C), and copying a selection start their process and move on.
    • The footer's memory readout no longer runs ps on the UI loop every five seconds.
    • FILE TABS an agent opens with nebula open read and highlight their files off the loop.
  • It is measured, not felt — make perf.
    • NEBULA_PERF_LOG=<file> turns on the INPUT LATENCY PROBE; off, it costs one Option check per event. It never records a typed character.
    • The harness drives the real TUI through every panel, modal and verb and prints handler · paint · settle · echo per step, plus peak RSS.
    • PERF_REPO=<path> measures against a large checkout; python3 scripts/perf/report.py BEFORE AFTER compares two runs.
  • Unchanged. Every key, every screen and every setting. The two loading states below are the only new pixels, and on an ordinary checkout they last under a frame.

📸 Screenshots

The new states are transient by design, so both shots hold git still for a few seconds (NEBULA_SHOT_SLOW_GIT_MATCH, a new knob on the SCREENSHOT HARNESS) — this is what a cold git on a large checkout shows for a moment.

f: the FILE FINDER is up and has taken read before git ls-files has answered g: the DIFF VIEWER ahead of a cold git status
The file finder open over the panels, titled "Find file — main (listing…)", the query "read" already typed, and "listing files…" where the rows will be The diff viewer open with its file panel titled "Files (…)" and the row "reading changes…", the diff pane empty beside it

And what make perf prints — the top of the release run the scorecard is taken from:

startup (m-final-release): first frame 21.0 ms, with the daemon's snapshot 26.1 ms
memory  (m-final-release): peak RSS — TUI 54.0 MB, daemon 19.7 MB
step                              key            handler   paint  settle    echo  frames  max draw
focus worktrees                   l                  0.0     0.7     0.7       -       1       0.7
worktree down                     j                  0.0     0.7     0.7       -       4       0.7
new session picker                n                  0.2     0.9     0.9       -       1       0.8
open diff                         g                  0.0     2.0    13.2       -       4       2.0
diff: next file                   Down               0.0     1.2     1.2       -       3       1.2
grep: type                        text:ATTACH_       0.0     3.9    66.1       -       3       1.2
archive session                   a                  0.1     1.1     1.1       -       4       1.0
delete: confirm                   y                  0.3     1.7     1.7       -       3       1.3
…
paint  p50    1.3 ms   max    6.0 ms   steps over 16 ms: 0/123

🧭 Where the time goes

flowchart LR
  subgraph echo["A character typed in a LOCKED PANE · 19.8 ms, now 2.3 ms"]
    K([key]) -->|"its own frame · was 1 ms plus a 16 ms cap behind it, now no frame"| I["Input to the DAEMON"]
    I -->|"PTY write and echo · unchanged, under 1 ms"| P["pty pump"]
    P -->|"coalescing hold · was 5 ms, now 0 after a quiet spell"| O["Output to the TUI"]
    O -->|"wait for the frame cap · was up to 16 ms, now a 2 ms gap"| F([echo painted])
  end
  subgraph diff["g on a ten-thousand-file checkout · 262 ms frozen, now 1.4 ms"]
    G([key]) -->|"was git status, rev-parse and git diff inline"| H["handler · 0.1 ms"]
    H -->|"opens on the badge's file list"| M([modal painted · 1.4 ms])
    H -.->|"BACKGROUND READ"| B["blocking pool: git diff, then git status"]
    B -.->|"ticketed answer"| L["land_view_answer"]
    L --> C([first diff · 59 ms])
  end
  classDef faster fill:#bbf7d0,stroke:#166534,color:#111
  classDef gone fill:#fecaca,stroke:#991b1b,color:#111
  class P,O,H faster
  class B gone
Loading

Red is work that left the event loop; green is a stage that got shorter.

⚠️ Risk

🎯 Attack surface

  • NEBULA_PERF_LOG — the TUI creates and truncates the file it names. Reached by whoever controls the TUI's environment, who can already run anything as the user. Held by: it is read once at start, writes with the user's own permissions, and logs timings, overlay and FOCUS names, session ids, and key classes — a plain or shifted character is always key:char, under test. Not held: a chord is named (key:C-q), and a run left on by accident grows a file until the TUI quits.
  • The DAEMON gains nothing. No new ClientRequest, route, exec or file. SendCloudMessage runs the same claude invocation it did, on its own task instead of the connection's.
  • The TUI's git is the same git. Every read still goes through git_diff::git_command (GIT_OPTIONAL_LOCKS=0); what changed is the thread it runs on. The streaming grep passes the query as an -e argument exactly as the inline one did.

Verdict: 🟡 Merge with care — no new surface and the gate is green, but it moves the frame loop, the PTY byte path and the vendored terminal emulator's scrollback at once, and a regression in any of those is felt on every keystroke.

Level Why
🔒 Security & production Low one opt-in env var that writes a timing log and never a typed character; no protocol, store or DAEMON surface change
Performance Medium it is the hot path: the draw cadence, the DAEMON's output flush, the pane cache. Bounded by construction — the bucket refills one frame per 16 ms so sustained output costs what it did plus two frames per burst, a stream still flushes once per 5 ms, the cache budget halved — and measured flat on RSS, but only on one machine
🧩 Fit with the codebase Medium BACKGROUND READS follow the per-feature channel pattern (issues_tx, branch_switch.tx), and optimistic rows reuse handle_server_event rather than a second code path; the departures are a fifth workspace crate that exists for a dev-profile override, and two accessors added to the vendored vt100

Rollback: git revert of the merge undoes all of it — no PROTOCOL VERSION bump, no store migration, no config key. What it does not undo: a running DAEMON keeps the new flush behaviour until it is restarted, and the two images stay on pr-assets.

🔧 Technical overview

  • Mechanism. The event loop is one task, so a handler that shells out freezes paint, PTY output and the next key. Handlers now only do bookkeeping: a view holding a view_jobs::Jobs handle runs its git or disk read on the blocking pool and the main loop lands the answer by ticket (land_view_answer), dropping any nobody is waiting for; a view built without a handle — every unit test — reads inline through the same parsers. While a read is out the pane keeps what it showed for 60 ms, then says loading…. Optimistic verbs hand handle_server_event the very upsert or removal the DAEMON is about to send, with the old row riding a PendingIntent::Undo.
  • Files.
    • crates/nebula-tui/src/event_loop/pacing.rs — the token bucket (BURST 3, MIN_GAP 2 ms, one token per 16 ms, never sooner than the draw itself took).
    • crates/nebula-tui/src/view_jobs.rsJobs, Answer, tickets, Cancel, the stale grace.
    • crates/nebula-tui/src/git_diff.rsread_listing, fill_view (reconciles the badge's list with the fresh one, reader kept in place), the cache and read-ahead in load_selected_diff / land_diff.
    • crates/nebula-tui/src/event_loop/optimistic.rs — the five verbs, App::deleting tombstones, undo / settled.
    • crates/nebula-tui/src/app.rsstash_term (six screens, one 400 k-cell budget, histories go before screens), AttachedTerm::drop_history, apply_output skipping bytes a replay covered.
    • crates/nebula-tui/src/event_loop.rstyping_into_pane, rehydrate_history, keep_changed_files, spawn_and_reap, the landing functions.
    • crates/nebula-daemon/src/pty/mod.rsflush_deadline: the hold counts from the last flush, not the first pending byte.
    • crates/nebula-fuzzy/ — the matcher, moved whole, plus a reusable Matcher with an allocation-free reject pass; [profile.dev.package.nebula-fuzzy] opt-level = 3.
    • vendor/vt100/src/{grid,screen}.rsscrollback_rows() and clear_scrollback(), marked NEBULA PATCH.
    • crates/nebula-tui/src/perf.rs, scripts/perf/ — the probe and the harness.
  • Rejected. Parsing a replay's tail first to paint sooner (modes and alt-screen state live anywhere in the ring); a server-side VT grid or a snapshot request (a locked v1 decision, and a protocol bump); raising this crate's dev opt-level (it is the one edited all day — hence the tiny matcher crate instead). Still true after this PR: the first visit to a session whose screen is not kept parses its whole ring, ~20 ms in release.
  • How the numbers were taken. One Mac, scripts/perf/run.sh driving the binary in a private tmux at 190×50 against an isolated DAEMON, one run per build per side; the baseline is main plus only the probe, built in its own target dir. Inputs and frames are stamped with the wall clock (macOS Instant drifts ~1.5 ms/s against it). Single runs — the git-bound rows moved ±30 % between runs on the large checkout; the frozen-versus-not rows did not.
  • Gate. make ci green on the first commit: fmt, clippy, every suite — 947 TUI and matcher tests, 280 DAEMON, e2e_pty 31, e2e_tui 11. The second commit (the probe's redaction, +2 tests) ran cargo fmt --check, clippy on nebula-tui and the TUI unit tests, not the e2e suites again.

📝 Notes

  • Branch is two commits on origin/main (12a31cc, v0.29.0); no conflicts.
  • Restart the DAEMON to get the echo fix (make cycle): the flush change lives there. Everything else is the TUI's and applies on its next launch. No PROTOCOL VERSION bump — a new TUI works against an old DAEMON, only without that one gain.
  • Two e2e_tui flows (nebula_open_from_inside_a_session_raises_the_file_tabs, tui_projects_worktrees_agents_navigation) were failing on untouched main: they waited for New session to disappear, and the NEW SESSION PICKER now ends in a task box with the same title. They follow the box by its hint line now, which also puts the async FILE TABS preview under an end-to-end test.
  • The harness deletes its cloned checkout on exit and writes results to target/perf/<stamp>. scripts/shot/shot.sh still leaves $TMPDIR/nebula-shot/<pid> behind — not touched here.

🤖 Generated with Claude Code

webdevcody and others added 3 commits September 17, 2026 11:22
…ssion switches are instant, and git-backed views never freeze the UI — measured, with memory flat

- The INPUT LATENCY PROBE (`NEBULA_PERF_LOG`) and `make perf`: the real TUI
  driven through 123 scripted steps in a private tmux, printing per step how
  long the key's handler held the loop, how long the key waited for its
  frame, how long the screen took to settle, the echo time in a locked pane,
  and the peak RSS of the TUI and the DAEMON. `PERF_REPO=` measures against
  a large checkout; `report.py BEFORE AFTER` compares two runs.
- FRAME PACING is a token bucket, not a 16 ms tick: a key's frame and its
  answer's follow 2 ms apart, sustained PTY output still paints at 60 fps.
  A key that only goes to the PTY paints no frame of its own, and the
  DAEMON flushes PTY output that breaks a silence at once instead of holding
  it 5 ms. Echo in a locked pane: 19.8 → 2.3 ms release, 24.9 → 3.5 ms dev.
- BACKGROUND READS (`view_jobs.rs`): the DIFF VIEWER, the FILE FINDER, its
  grep view, the TREE BROWSER and the FILE TABS open on the keypress and
  fill in when git and the disk answer; what is typed meanwhile is kept.
  `git grep` is debounced, streamed and killed at the result cap. `open`,
  `pbcopy` and the footer's `ps` no longer run on the loop. On a
  ten-thousand-file checkout `g` went from 262 ms of frozen UI to a list in
  1.4 ms, and grep from 370 ms frozen per keystroke to none.
- The DIFF VIEWER opens on the changed-files badge's last `git status`,
  reads the row after the cursor ahead and keeps up to 2 MB of diffs while
  it is open; only the visible lines are styled.
- OPTIMISTIC UPDATES (`event_loop/optimistic.rs`): rename, archive,
  unarchive, delete and close-terminal change the row on the keypress by
  way of the event the DAEMON is about to broadcast; an Error puts it back.
- The pane's screen cache keeps six screens inside half the memory two used
  to be allowed: a screen over budget lets its scrollback go, and scrolling
  up replays the ring once. Session, worktree and workspace switches settle
  in 1.3–1.7 ms where they took 19–22.
- `nebula-fuzzy`: the list filters' matcher in a crate of its own so dev
  builds optimise it, without per-candidate allocations — 27 → 5 ms a
  keystroke over ten thousand paths in the build `make dev` runs.
- `SendCloudMessage` runs off the DAEMON's request loop. Two e2e flows
  follow the NEW SESSION PICKER into its task box (they failed on main).
  The shot harness can slow any git subcommand, with scenes for the two
  new loading states.

Peak RSS over the scenario: TUI 55.6 → 54.0 MB, DAEMON 21.7 → 19.7 MB.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`NEBULA_PERF_LOG` times keys; it does not record them. A plain or shifted
character is `key:char` in the log — it is as likely typed at an agent, a
shell or a password prompt as at a panel, the rule the KEY COMBO DISPLAY
already keeps — while chords and named keys (`key:C-q`, `key:Enter`) still
say which command they were. The report reads step labels from the driver,
so nothing it prints changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
v0.30.0 folded the DIFF VIEWER's file list into a directory tree and gave
rows a one-shot sweep; this branch moved every git read off the event
loop. They met in the same five files.

How the two were stitched:

- The tree is one more shape of the same list, so the BACKGROUND READS ask
  it, not the flat list, what the cursor is on: `load_selected_diff` shows
  a directory row's summary instead of waiting on a diff, the read-ahead
  walks to the next file row (`DiffView::file_after_cursor`), and
  `fill_view` folds the fresh listing into the tree the reader had up —
  keeping their folds — before homing the cursor on the first unreviewed
  file, in either list (`DiffView::at_home`).
- `g` opens in the shape the reader left the modal in: the optimistic open
  on the changed-files badge builds the tree before the `git status` it
  opened ahead of lands.
- `restore_reviewed_marks` is gone: checking the stored marks is a diff per
  marked file, and that now happens inside `read_listing`, off the loop.
- The tree browser's row walk is main's shared `visible_rows` (the diff
  tree folds through it as well), with this branch's one reusable
  `fuzzy::Matcher` for the whole walk rather than one per path.
- The screen cache keeps six sessions bounded by a shared cell budget
  (this branch), alongside main's ONE_SHOT_SWEEP.

make ci is green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@webdevcody
webdevcody merged commit 9fd3ab3 into main Sep 17, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant